Skip to content

urtest: Convert AuTest tests to pytest - #13545

Open
bneradt wants to merge 27 commits into
apache:masterfrom
bneradt:pytest-replay-tests
Open

bneradt wants to merge 27 commits into
apache:masterfrom
bneradt:pytest-replay-tests

Conversation

@bneradt

@bneradt bneradt commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

This replaces Traffic Server's AuTest end-to-end suite with Uranium, a pytest
test framework for Proxy Verifier replays and native Python scenarios. There is
no AuTest compatibility backend in the resulting test suite.

Summary

  • make pytest own the ATS end-to-end test inventory
  • collect direct Proxy Verifier manifests named <scenario>.test.yaml; each
    manifest contains both its urtest configuration and replay sessions
  • express tests that need custom clients, servers, or control flow as native
    pytest scenario classes with an explicit run() entry point
  • schedule parallel work through pytest-xdist while isolating sandboxes, ports,
    and shared/exclusive scenarios
  • provide shared ATS, origin, DNS, curl, process, replay, and gold-file helpers
    under tests/tools/uranium
  • replace AuTest entry points and configuration with urtest.sh, Uranium CMake
    options and targets, and urtest replay metadata

Direct replay manifests live in an existing replay/ or replays/ directory
when a test tree has one. The at_headers manifest intentionally remains next
to its plugin assets.

Developer workflow

Run selected tests with pytest's normal selection and parallelism options:

./tests/urtest.sh -q -k cache_control
./tests/urtest.sh -q -n 8 -k "header_rewrite or cache_control"

A configured tree generates <build>/tests/urtest.sh for running against its
installed ATS tree. Manual tests are skipped by default and can be selected
explicitly with --run-manual -k <expression>.

Container behavior

The source-tree runner defaults to
ci.trafficserver.apache.org/ats/fedora:44, performs an incremental dedicated
build/install, and runs pytest there. It avoids nested Docker whenever it is
already inside any container. --run-in-docker and --no-run-in-docker
override that choice.

The official Fedora image also enables the optional cdifflib acceleration for
large gold-file comparisons. Other environments use it when installed and
fall back to the standard-library difflib implementation otherwise.

Compatibility

This is an intentional cutover rather than a deprecation shim. The AuTest
runner, backend, CMake targets, presets, and options are removed. CMake reports
a fatal migration message if removed ENABLE_AUTEST, ENABLE_AUTEST_UDS,
AUTEST_SANDBOX, AUTEST_OPTIONS, or PYTEST_OPTIONS variables are supplied.
Existing CI scripts now invoke Uranium and pass pytest's -k and -n options.

SHARD and SHARDCNT continue to distribute pytest items across CI shards.

Validation

  • Fedora 44 build and install completed successfully
  • all 371 direct replay variants: 356 passed and 15 capability skips
  • 51 Uranium framework unit tests passed
  • the converted client-certificate update scenario reaches its strict expected
    failure for the pre-existing TSSslClientCertUpdate defect
  • Sphinx documentation completed with warnings treated as errors
  • replay manifests parsed and all referenced client/server replay files exist
  • Python static checks, CI shell syntax checks, formatting, and whitespace
    checks passed

@bneradt bneradt added this to the 11.0.0 milestone Aug 13, 2026
Copilot AI lite review requested due to automatic review settings August 13, 2026 18:34
@bneradt bneradt self-assigned this Aug 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@bneradt
bneradt marked this pull request as draft August 13, 2026 18:37
@bneradt bneradt changed the title Add pytest replay test framework Unify end-to-end tests under pytest Aug 13, 2026
@bneradt bneradt changed the title Unify end-to-end tests under pytest urtest: Convert AuTest tests to pytest Aug 14, 2026
@bneradt
bneradt force-pushed the pytest-replay-tests branch from 1b8339e to 678cb46 Compare August 14, 2026 17:29
@brbzull0
brbzull0 requested a lite review from Copilot August 17, 2026 12:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@bryancall bryancall left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as a comment rather than a formal review since it is still a draft. There is a lot of good work here, and two things that I think need to come out of this PR before it goes further.

First the good, because the framework itself is well built:

  • Replay manifests are collected as first-class pytest items, so each scenario gets its own result and sandbox instead of hiding behind a thin registration wrapper.
  • Validation parity with AuTest is preserved rather than quietly dropped. diags.log is still scanned for ERROR:, FATAL: and unrecognized configuration values, background processes are checked for premature exit, and _validate_gold reproduces the {} and backtick wildcard semantics of the old matcher.
  • Cross-worker port allocation uses a flock'd counter plus a bind probe, which is a genuine improvement over a per-process allocator.
  • Sandbox paths are deliberately short and hashed with the rationale tied to sockaddr_un.sun_path's 108-character limit written down, and prepare_sandbox refuses to rmtree anything that is not a direct child of the sandbox root.
  • The consolidation is real rather than lossy, and the framework ships its own unit tests instead of being validated only by the suite it runs.

Two production changes are buried in the test migration

This is my main concern and it is a process point rather than a code-quality one. Copilot declined to analyze this PR because it exceeds its file limit, so these two changes have had no automated review either, and at 2414 files no human is going to find them by reading.

src/api/InkAPI.cc:8240 TSSslClientCertUpdate()'s lookup key changes from swoc::bwprint("{}:{}", cert_path, key_path) to key.assign(cert_path).

I traced this rather than assuming. The inner map is keyed by certificate path alone: SSLConfig.cc:952 sets ctx_key = client_cert, and the only producer of that key, SSLSNIConfig.cc:185, stores a fully resolved absolute path, which is what the cert_update plugin passes via traffic_ctl plugin msg. So on master the composed cert:key string can never match, the API always falls through and returns TS_ERROR without touching anything. After this change it locates the bucket, calls SSLCreateClientContext and swaps the shared context under ctxMapLock.

That is a dead-to-live transition on a public TSAPI affecting shared outbound TLS state. The fix looks correct to me. The problem is that its only covering test, cert_update.test.py, is deleted and rewritten in the same commit range, so a green run cannot distinguish "the API fix works" from "the rewritten test no longer asserts the same thing". This needs to land in its own PR against an unmodified test.

src/proxy/http/HttpTransact.cc:6399-6410 A new proxy.config.http.cache.max_stale_age_percent record, added end to end: RecordsConfig.cc:663, HttpConfig.h:708, OverridableConfigDefs.h:255, a new TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT inserted before TS_CONFIG_LAST_ENTRY in apidefs.h.in:921, cripts/Configs.hpp:104, and the clamp logic in is_stale_cache_response_returnable(). None of it exists on the base and none of the seven commit messages or the PR body mention it.

In fairness: the default is 0 and the arithmetic collapses to the exact master expression at 0, so no behavior change ships. It is still a new proxy.config.* record and a new TSOverridableConfigKey, which is API surface the project supports forever, and it touches the stale-serving threshold used by negative revalidating and open_write_fail_action, which is load-shedding-critical.

One substantive note on the logic itself if it does get its own PR: get_max_age() returns 0, not -1, when a max-age directive is present but zero. So with the percent knob enabled, max_stale_age becomes 0 and any nonzero age fails the returnability test. An origin sending Cache-Control: max-age=0, which is common in front of revalidating origins, stops being served stale entirely during an outage. The documentation covers the absent case but not the present-and-zero case.

The Jenkins entry point is left in a non-working state

ci/jenkins/bin/autest.sh:123

The entire change to this file is one line, -D ./tests/gold_tests to -D ./tests/uranium_tests. Lines 107-108 still resolve AUTEST=/usr/bin/autest and line 123 still executes it. I counted the new tree: tests/uranium_tests has 0 files matching *.test.py, which is AuTest's only collection pattern, against 233 *.test.yaml and 303 test_*.py, and tests/gold_tests no longer exists. tests/pyproject.toml also drops the autest dependency.

So this script either runs a discovery pass over an inventory it cannot see and reports green having executed nothing, or fails for a reason unrelated to the change under test. ci/coverage and ci/regression were correctly updated to call tests/urtest.sh; this one was missed. A CI job that silently runs zero tests is worse than one that fails.

The deprecation story does not hold

CMakeLists.txt:164-165

The PR body says temporary deprecated aliases retain ci-fedora-autest, autest.sh, the old CMake options and targets. I checked each: option(ENABLE_AUTEST) and option(ENABLE_AUTEST_UDS) are replaced outright, the three gates now test ENABLE_URTEST only, and tests/CMakeLists.txt renames the autest, autest_no_install and autest-uds targets with no ALIAS or forwarding target. CMakePresets.json has only ci-fedora-urtest, and there is no tests/autest.sh. The only deprecation shims are AUTEST_SANDBOX, AUTEST_OPTIONS and PYTEST_OPTIONS, and those live inside if(ENABLE_URTEST), so they never fire for a caller passing the old option.

CMake treats an unconsumed -DENABLE_AUTEST=ON as an unused-variable notice, not an error, so an external pipeline configured that way configures successfully, silently skips add_subdirectory(tests), and produces no test target at all. A job using the old preset fails loudly instead, which is the better outcome. Either add real aliases or state plainly in the body that the cut-over is not backward compatible.

Framework items worth fixing while it is still a draft

  • tests/tools/uranium/runner.py:225 is_official_test_container() requires an exact ID == "fedora" and VERSION_ID == "44" match, and choose_docker_mode() defaults to Docker when that fails. When the CI image is bumped to Fedora 45 in some unrelated PR, urtest inside that container stops recognizing it, defaults to Docker mode, finds no docker client, and every shard dies. The safe default when already inside any container is to run directly.
  • tests/tools/uranium/replay.py:657-668 _check_metrics sleeps a fixed interval then reads each metric exactly once with no retry. _check_files in the same file already does this correctly with a 100ms deadline poll. 17 manifests use metric_checks, and sleep-then-assert-once under 8-way xdist is precisely the flake pattern this migration is meant to leave behind.
  • tests/tools/uranium/runtime.py:38 class RuntimeError(ValueError) shadows the builtin for the whole module, while process.py:29 defines ProcessError(RuntimeError) against the real builtin. Two unrelated hierarchies with the same spelling in one package.
  • tests/tools/uranium/replay.py:749 and :793 read_text() with no existence check, so a missing diags or gold file surfaces as a raw traceback into framework internals rather than the intended message. Every sibling path in the same class already guards with if path.exists().

One claim I checked and am withdrawing before anyone chases it: I initially thought ReplayItem.runtest hardcoding is_exclusive=False broke serial_tests.txt for replay manifests. On re-reading it is latent rather than live, so it is worth tidying but it is not causing anything today.

@bneradt
bneradt force-pushed the pytest-replay-tests branch from c654ac4 to c680be9 Compare August 18, 2026 22:20
@bneradt

bneradt commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I addressed the feedback and force-pushed the amended commit.

  • Removed both unrelated production changes from this PR. The max-stale percentage feature landed separately in Add percentage limit for stale cache age #13547. The converted certificate-update scenario is now a strict expected failure for the pre-existing TSSslClientCertUpdate defect, which can be fixed separately.
  • Updated the Jenkins, coverage, and regression scripts to invoke Uranium and use pytest's -k and -n options.
  • Kept this as an intentional clean cutover rather than an AuTest compatibility shim. Removed AuTest CMake variables now produce explicit migration errors, and I updated the PR description accordingly.
  • Changed automatic execution to avoid nested Docker in any detected container, while retaining the explicit Docker override flags.
  • Changed metric checks to poll until their deadline.
  • Added explicit assertions for missing actual, gold, and diagnostic files.
  • Renamed the framework's local RuntimeError to RuntimeConfigError.

I also normalized direct replay placement, fixed the stale documentation includes, and reran the relevant validation: the documentation build passes with warnings treated as errors, all 371 replay variants passed or reached expected capability skips, and the 51 framework unit tests pass.

@bneradt

bneradt commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

[approve ci autest]

@bryancall

Copy link
Copy Markdown
Contributor

I benchmarked this migration on dedicated hardware rather than reasoning about it, because a harness swap of this size deserves numbers. Summary up front: Uranium is 7.4x faster at 32-way parallelism, loses no code coverage, uses less memory, and has fewer flaky tests. Details and methodology below, including the parts that do not favour the new harness.

Methodology

Two hosts, both 32 hardware threads, 30 GB RAM, Fedora, gcc 16.1.1, Proxy Verifier v3.1.3 on both sides (verified identical, checksum 342286244d...). Timing on one host, coverage on the other, and each host ran both harnesses so every comparison is within-host.

  • master at a2ea029215, this branch at 7fcbc9d69
  • Python pinned identically for every run, dependencies resolved from the checked-in tests/uv.lock
  • Timing used Release builds; coverage used separate gcov builds, since -O0 plus instrumentation distorts wall clock by about 1.5x
  • Coverage counters were reset before each run and only the integration suite was measured. Unit tests are identical on both sides and including them would dilute exactly the delta in question.

The proxy binary is the same on both sides. I checked this rather than assuming it, because the whole comparison depends on it. After normalizing embedded build paths, both binaries disassemble to 1,341,244 instructions and the instruction streams are identical once addresses are masked. The residual byte differences are relocation displacements from embedded path and timestamp strings of different lengths, spread across 1,265 unrelated symbols in single-byte runs. So every number below is harness overhead, not a proxy change.

Performance

Full suite, same host, same binary:

Workers AuTest Uranium Speedup
4 3230s 914s 3.5x
8 2254s 497s 4.5x
16 1749s 293s 6.0x
32 1591s, 1596s 215s, 215s, 202s 7.4x

The gap widens with worker count, which points at the mechanism:

AuTest Uranium
Total work 4873s of worker time 3899s of test time
Wall clock 1031s 215s
Effective parallelism 4.7x 18.1x

AuTest's worker durations at -j32 were min 113s, median 155s, max 889s. One worker set the wall clock while roughly 30 sat idle behind it.

The cause is that AuTest's load balancing never engages. autest-parallel.py has an LPT balancer that reads <sandbox>/test-timings.json, but that file is never written: the save at line 1135 is gated on tests_timed > 0 and the per-worker timing dictionaries come back empty. Every run logs Using round-robin partitioning verbatim, and I confirmed no timings file exists after seven runs. This is not a cold-start artifact, it is the steady state. Worth knowing regardless of this PR's outcome, since it means the current suite is leaving most of the machine idle.

Uranium's per-test distribution for reference: p50 1.13s, p95 9.17s, max 90.6s.

Code coverage

gcov plus gcovr 8.6, identical flags and exclusions on both sides, integration suite only:

AuTest Uranium Delta
Lines 51.0% (96,576 / 189,482) 51.1% (96,784 / 189,482) +208
Functions 60.7% (11,376 / 18,726) 60.8% (11,392 / 18,726) +16
Branches 25.6% (77,497 / 302,664) 25.6% (77,583 / 302,664) +86

No coverage is lost. Uranium is fractionally ahead on all three measures. This is the number that matters most, since a 7.4x speedup naturally raises the question of whether the suite is simply doing less. It is not. The denominators are identical on both sides, which independently corroborates the binary-equivalence check above.

Memory

Peak resident memory across the whole process tree:

Workers AuTest Uranium
4 3.78 GB 3.66 GB
8 5.48 GB 4.52 GB
16 7.47 GB 5.69 GB
32 11.0 to 12.26 GB 9.64 to 10.03 GB

Uranium uses less memory at every level, with the gap widening to about 31% at 16 workers. Neither harness came close to exhausting 30 GB.

Caveat worth stating: these are summed RSS across all processes, which double counts pages shared between the many concurrent Traffic Server instances, so treat them as upper bounds. The idle baseline was 0.97 GB, so roughly 92% of each figure is genuine workload. I am re-measuring with proportional set size to remove the double counting and will follow up if it changes the picture.

Flakiness

Seven AuTest runs and six Uranium runs, across all parallelism levels:

Harness Test Failed in Verdict
AuTest per_client_connection_max 7/7 deterministic
AuTest cripts 7/7 deterministic
AuTest h2_malformed_request_logging 2/7 flaky
AuTest session_id 1/7 flaky
Uranium test_all_bespoke_tests_are_available_to_pytest 6/6 deterministic
Uranium log_mstsms 2/6 flaky

Two observations that only a repeated run surfaces.

A flaky test was distorting the timing. AuTest at -j32 came in bimodal: 1031s, 1039s, then 1591s, 1596s. The fast runs are the ones where h2_malformed_request_logging failed; the slow ones are where it passed. Its success path costs roughly 550 seconds more than its failure path. A single measurement would have reported a figure that flattered AuTest by 35%.

AuTest does not run a deterministic set of tests. Executed totals across runs were 586, 586, 586, 586, 586, 583, 581. At -j8 three tests silently disappear and at -j4 five do, with no diagnostic. Uranium collected exactly 1207 items in all six runs with no drift. For a suite whose purpose is regression detection, that difference matters as much as the speed.

Test inventory

I mapped all 564 master *.test.py files against the branch. 388 matched by directory and stem; I resolved the remaining 176 individually by reading each successor rather than inferring from names.

Exactly one test has no successor: tests/gold_tests/cache/cache-write-lock-contention.test.py. Severity is low since it was already SkipUnless(RUN_CACHE_CONTENTION_TEST=1), so CI signal is unchanged, but the scenario is gone and the gate variable is now dead plumbing: tests/tools/uranium/runner.py still forwards RUN_CACHE_CONTENTION_TEST into the container and nothing reads it. Either restore the scenario or drop the plumbing.

Everything else is verified consolidation, and the counts hold up: the 18 tls_hooks files became a 17-entry parametrization plus one function, the 7 cont_schedule files became a 7-entry parametrization, 38 txn_box tests became 35 manifests with three merged pairs, and cache grew from 39 to 70. I also confirmed the master merge converted the two tests #13547 added after my baseline rather than dropping them.

Other findings

  • ci/coverage cannot collect coverage as written. It calls the build-tree ./urtest.sh -n "$NPROCS" without --no-run-in-docker. Since choose_docker_mode() returns not is_container(), on any non-container host that shells out to Docker and discards the gcov-instrumented build the script just made. I had to bypass this script to get the coverage numbers above.
  • ci/regression and ci/jenkins/bin/autest.sh no longer exercise the tree they build. Source-mode urtest.sh always runs its own cmake --preset urtest into build-urtest-container, so $DSTROOT and ${INSTALL} are ignored and the build above the test call is dead weight.
  • tests/uv.lock never reaches the build tree. tests/CMakeLists.txt copies only pyproject.toml, but runner.py runs uv --project <build>/tests, so the vetted lock is unused and every first run resolves fresh against PyPI. This bit me concretely: without an explicit pin, uv selected Python 3.12.12 on a host whose system interpreter is 3.14. Copying uv.lock alongside pyproject.toml is a one-line fix and makes runs reproducible.
  • test_all_bespoke_tests_are_available_to_pytest fails deterministically, in all six runs. A framework self-check reporting that the declared inventory does not match what pytest collects is worth resolving before this lands, since it is the test that would otherwise catch a conversion gap.
  • The documentation contradicts the code on Docker detection. uranium-tests.en.rst describes a two-condition rule (container and Fedora 44); choose_docker_mode() checks only is_container().
  • if(DEFINED ENABLE_AUTEST) fires even for -DENABLE_AUTEST=OFF, which will surprise anyone carrying that flag in a script. The companion guard for AUTEST_SANDBOX / AUTEST_OPTIONS / PYTEST_OPTIONS sits inside if(ENABLE_URTEST), so it misses the common case.
  • Replay manifests cannot be marked serial. _is_serial_test matches only .py paths and ReplayItem.runtest() always takes the shared lock, so a .test.yaml listed in serial_tests.txt would be silently ignored. Latent today since the file lists only .py entries, but it is a trap for whoever first needs an exclusive replay.
  • Sharding moved into the repository, which the PR body understates. SHARD and SHARDCNT appear nowhere on master; the 1of4 through 4of4 split is external Jenkins configuration. This PR implements sharding in-repo as a modulo stripe over sorted node IDs, so any existing per-shard intuition about which tests land where is void, and ci/jenkins/bin/autest.sh now passes no shard flags at all. Worth confirming the Jenkins job definitions move in lockstep.

Overall

The performance and determinism case is strong and I would not have predicted the margin. The two things I would want resolved before this leaves draft are the ci/coverage Docker bug, since it silently disables coverage collection, and the deterministic test_all_bespoke_tests_are_available_to_pytest failure. The single lost test and the uv.lock plumbing are small and easy.

Happy to share the raw logs, per-test timing data, or the gcovr HTML reports for either side.

@bneradt
bneradt marked this pull request as ready for review August 19, 2026 17:46
Copilot AI review requested due to automatic review settings August 19, 2026 17:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@bneradt
bneradt force-pushed the pytest-replay-tests branch from 7fcbc9d to f3d4785 Compare August 19, 2026 19:24
Copilot AI review requested due to automatic review settings August 19, 2026 19:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Copilot AI review requested due to automatic review settings August 19, 2026 20:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

bneradt added 25 commits September 21, 2026 10:51
Procedural Uranium tests need reusable process ownership and curl
targeting that works for multiple ATS instances and Unix sockets.
The test tooling layout and command line also made framework code
difficult to distinguish from the ATS test inventory.

This patch adds fixture-owned ATS factories and ATS-aware curl
requests, including UDS support, and converts representative basic
coverage to the procedural API. It moves the harness and its unit
tests under tests/tools/uranium, updates imports, and lets pytest own
selection, parallelism, and collection options while the wrapper
handles Docker execution.
The remaining compatibility tests still depended on AuTest process
orchestration, which prevented the Uranium suite from being fully native
and made parallel execution unreliable.

This removes the compatibility backend and converts each test to direct
Proxy Verifier replay metadata or a native pytest scenario. It also
hardens process startup, logging, reload timestamps, DNS, and timeout
handling so the complete suite runs reliably with eight workers.

The test guide and runner documentation now describe the replay-first
workflow and pytest-native selection and parallelism.
The initial pytest migration left explicitly disabled scenarios as
permanent skips and made native curl commands cumbersome to read and
write. Repository guidance also retained obsolete AuTest conventions.

This patch restores the opt-in scenarios behind a pytest manual marker
and adds --run-manual for deliberate execution. It also changes Curl to
parse one shell-style argument string, updates every call site, and
documents parameter and timeout expectations.

This adds collection and service regression coverage and refreshes the
Uranium documentation to match the native pytest workflow.
Procedural Uranium support had accumulated unrelated process,
client, and assertion behavior in one large module, making changes
difficult to isolate while scenarios depended on its public import path.
The converted log filename scenario could also read asynchronously
written logs before every destination had flushed.

This patch addresses this by moving focused implementations into a
services package behind the existing tools.uranium.services facade and
updating internal imports, documentation, and facade coverage. It also
waits for each expected log record before asserting so parallel runs do
not race ATS logging.
Replay processing crosses pytest collection, YAML validation, and
runtime execution, but the framework directory did not explain those
boundaries. Closely related modules were therefore difficult to
distinguish.

This patch addresses this by documenting the direct replay and
procedural call flows, each module and supporting directory, and
guidance for placing new framework code.
Split Uranium manifests force readers to move between orchestration
metadata and Proxy Verifier traffic when reviewing a single scenario.

This patch embeds each file-backed default replay into its collected
test YAML and removes the redundant companion files. It also adds an
inventory check to keep default replay traffic self-contained.
Variant-specific and process-specific replays remain separate where
they describe different traffic.
The migration still mixed replay layouts, retained stale documentation
references, and left several framework and CI edge cases unresolved.

This patch groups direct manifests under replay directories, hardens
checks, corrects container and Jenkins execution, and drops unrelated
code changes from the migration.
Several converted tests lost AuTest matching and staging semantics or
relied on fixed timing. Rebasing also replayed an older migration change
that silently removed a newly merged cache configuration feature.

This patch restores the upstream cache feature, stages Cripts sources
where ATS searches for them, preserves the HTTP/2 counter's numeric
variability, and waits for STEK cluster convergence. It also updates the
native-test inventory for the test added on master.
Microserver health checks could claim empty header-only lookup keys,
making missing-origin responses depend on replay file load order.
Uranium also retained successful sandboxes, causing Jenkins to copy
gigabytes of irrelevant output when any test failed.

This patch gives health checks distinct custom lookup-header values.
It removes successful sandboxes after teardown while retaining failed
sandboxes for diagnosis.
The Go client can complete before ATS has flushed all access-log
entries. Waiting for two generic HTTP/3 entries therefore made the
scenario inspect a partially written log and fail intermittently.

This patch waits for the required large-POST entry before it checks
both expected records. This preserves the assertions while removing
the logging race.
The slow-post abort test can fail during ATS startup when the source
checkout is mounted under directories that the dropped service user
cannot traverse, even though the certificate files themselves are
readable.

This patch copies the certificate and key into the ATS runroot SSL
directory so their permissions and path accessibility match the rest of
the test configuration.
Parallel CI can delay access-log buffer flushing beyond ten seconds,
and the rate-limit driver can unlink its FIFO before the background
holder opens it. These races fail without exercising invalid ATS
behavior.

This patch allows a bounded 60-second log wait and retains the FIFO
until the holder is released so both tests observe their intended state
under a loaded VM.
CMCD prefetch validation could issue its cache-hit request before the
asynchronous next-hop fill released its cache write lock. Slow CI hosts
then observed a legitimate WL_MISS and failed intermittently.

This patch replaces fixed sleeps with synchronization on the relevant
next-hop transaction and cache write gauge. The hit assertions remain
unchanged while the setup no longer races the asynchronous fill.
Augmented assignment made process-output expectations easy to mistake
for ordinary assignment. A typo could discard prior checks and let a
test lose coverage without an immediate error.

This patch introduces read-only stream expectation objects with explicit
methods for regex, gold-file, reset, and return-code behavior. It also
preserves captured output through clearly named text properties and adds
focused misuse and validation coverage.
New AuTests landed on master while the pytest migration was under
review, so rebasing without porting them would silently drop their
coverage. Recent per-server metric changes also require derived-stat
synchronization to avoid racing assertions.

This patch converts replay-compatible coverage into combined manifests
and extends the native connection-limit scenario for aggregate,
hidden, and remap-overridden metrics. It polls derived metrics after
shortening the sync interval, preserving the original behavior without
fixed timing races.
A new AuTest landed while the pytest migration was under review, so
leaving it in the old suite would silently drop its API coverage.

This patch converts the custom-listener test to a native Uranium
scenario. It uses a Python socket client and verifies the plugin accept
callback through its diagnostics.
Uranium's source launcher assumed Docker and relied on Docker-specific
markers. Developers using Podman or Apple container could not start the
test image, and markerless Apple containers attempted an unavailable
nested Docker launch.

This patch selects Apple container on macOS and Podman on Linux, with
Docker as a fallback. It introduces runtime-neutral flags while
preserving the Docker aliases, marks managed launches explicitly, and
recognizes Fedora 44 for manually entered Apple containers.
Preserve the coverage added on master while keeping the rebased branch free of AuTest files. Fold replay-friendly cases into existing YAML and use native scenarios for tests requiring custom clients or process control.
Make bare pytest and editor discovery work from the source tree, keep the Python environment locked, and resolve helper scripts from that environment. Support the macOS loader path and document the native workflow.
Wait for live verifier output before inspecting multiplexer copies, and flush HTTP access logs promptly. This removes races exposed by the full parallel test run without weakening the original assertions.
Connection coalescing could assign an origin socket to a queued
transaction that did not own its connection-tracker reservation. The live
connection was then absent from per-server limits and metrics.

This patch keeps the reservation with the in-progress connection and
transfers it to the established session. Failed connection attempts
release the reservation from the coalescing entry.

Fixes: apache#13605
Master added and updated AuTests while this branch was converting the
suite to pytest. Leaving those files behind would silently drop their
coverage once Uranium becomes the only runner.

This patch ports replay-compatible cases to self-contained YAML and
rewrites custom-client cases as native scenarios. It also carries
forward the accompanying assertions and hardens asynchronous checks
exposed by eight-worker runs.

Co-authored-by: Codex gpt-5.6-sol xhigh
Worker-number directories made retained Uranium artifacts difficult to
navigate because a test owner was not visible at the sandbox root.
Failures from parallel runs therefore required extra lookup work.

This patch names each item directory from its pytest identity plus a
stable digest and places it directly under the shared root. The names
remain short enough for ATS Unix sockets, while shared locks and port
allocation preserve xdist safety.

Co-authored-by: Codex gpt-5.6-sol xhigh
Shortened sandbox names and generated suffixes made test artifacts hard
to identify, while automatic cleanup prevented inspecting passing runs.

This patch uses full test names and clears named directories on rerun.
It adds optional retention of passing sandboxes and rejects colliding
names. Unix socket paths stay short independently of artifact paths.

Co-authored-by: Codex gpt-6-astra medium
Changes on master left new tests outside the Uranium conversion, while
review exposed checks that could pass on incomplete evidence. Shared
sandbox names also needed protection against concurrent owners.

This patch preserves upstream cache, shutdown, compression, and metric
coverage in Uranium. It validates process lifetimes and settled output,
rejects unsupported replay assertions, and isolates sandbox ownership
without shortening test names. Regression tests exercise the harness
failure paths as well as redirected diagnostic output.

Co-authored-by: Codex gpt-6-astra medium
@bneradt
bneradt force-pushed the pytest-replay-tests branch from 57c0465 to 8988b68 Compare September 21, 2026 16:21
Copilot AI review requested due to automatic review settings September 21, 2026 16:21
@bneradt

bneradt commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

@bryancall Thanks for the detailed assertion-by-assertion review. I rebased onto master 7741a2b237, converted the newly added cache tests, and retained both the busy and quiet Lua shutdown scenarios added upstream.

The review changes are:

  1. Native ATS teardown now fails on an unexpected exit, including status 0 and signal exits. Explicit stop/kill and validated one-shot waits remain supported. A missing diagnostic destination fails too; renamed logs and stdout/stderr are checked at their configured destination. Regression tests cover these cases.
  2. Background-fetch and the affected abuse-shield negative assertions now read the complete output after stopping ATS. Reload polling requires the reload to leave in_progress before accepting its assertions.
  3. Replay file_checks now implements excludes, rejects unknown keys (including nested match keys), and enforces line_count_min after its polling deadline. Regression tests exercise rejected assertions and invalid manifests.
  4. Every abuse-shield instance now verifies the startup rule count from its configured rules before sending traffic.
    5–8. Embedded replays use separate child directories under the native scenario, including distinct directories for repeated calls and variants. Collision diagnostics are printed before raising so they remain visible with xdist. A controller-held exclusive sandbox-root lock rejects concurrent sessions before they can reset the port counter or delete artifacts; the diagnostic tells callers to choose a different --sandbox.

On point 7, I deliberately kept the full readable test names requested for this branch instead of restoring truncation. Both JSON-RPC and HTTP Unix sockets use short paths independently of those names; a regression test binds both actual sockets under long sandbox/process names. Unrepresentable filesystem components now fail explicitly. This does not impose a blanket limit for arbitrary future path-limited artifacts; those would need the same short-path treatment.

9–11. The record-triggered reload uses bounded polling, with a fresh trigger marker so an earlier reload cannot satisfy it. rpc-greet rejects all returned errors. The abuse-shield checks again require for IP=, and the shared-log-interval check requires exactly 127.0.0.1.

  1. I retained the slice diagnostic alternation: it is already present in the original AuTest, together with the timing explanation (original lines 295–304). An abort can arrive before or after the response header. The converted test still requires client failure, failed transaction identity, and the specific block/ETag mismatch diagnostics. I brought the explanatory comment across as well.

  2. Runroot verification no longer recursively changes permissions. It verifies the copied tree as the initializing user with --with-user, leaving copied file modes untouched, and adds a deliberately unwritable log to require verification to fail.

  3. Only the locked session controller initializes the port counter. Missing/corrupt/exhausted state produces a path-bearing error; only EADDRINUSE retries, while other bind errors report the address and OS error. Regression tests cover these cases.

  4. The two ineffective native disable_log_checks flags were removed.

I left the inherited idle_connections.py behavior unchanged in this conversion. The connection-rate scenario additionally requires the rule-match log, blocked-action metric, and rejected-connection metric, so a single successful connection cannot by itself make that scenario pass. The helper's partial-success reporting remains a separate follow-up.

Validation on the rebased branch:

  • Build/install and the full CMake formatting target succeeded.
  • All 1,116 CTest cases passed.
  • All 103 Uranium framework tests passed.
  • The final eight-worker selection covering cache tests and review-affected scenarios finished with 143 passed, 4 skipped. This includes the new upstream cache replay, both Lua shutdown paths, renamed/stdout/stderr diagnostics, reload tests, runroot verification, and abuse-shield scenarios.
  • An earlier full eight-worker run finished with 1,260 passed, 39 skipped, and one teardown error in the renamed/redirected-log scenario. That exposed the diagnostic-destination edge case described above; the fix is covered by both regression tests and the successful final scenario run. I have not rerun the entire suite after the final rebase.

There are no inline review threads to resolve; points 7 and 12 above are explicit responses rather than changes to the requested naming policy or the original slice contract.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@bryancall bryancall left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Comment

Eleven of my fifteen asks are fixed, three are partly fixed, and one is not addressed. I am leaving the existing request for changes in force rather than submitting a second one, since that would not change the state, and the branch needs a rebase anyway (mergeable_state: dirty).

Before the open items, the thing I most want to say: you did not just add assertions, you added tests that the new assertions can fail. Four of them, and they are the reason I trust these fixes rather than merely reading them.

Fixed: ask 1, ATS liveness

services/ats.py:380-385. The gate raises when return_code is not None before teardown, and ManagedProcess.return_code is a live poll() on traffic_server itself rather than a cached field or a wrapper, so it observes a crashed proxy. It reaches every instance through ATSFactory.close()'s ExceptionGroup and the fixture's finally, which is broader than the five sites I named. ats.py:646 also inverts the missing-evidence shape so an absent diags log is an error rather than "nothing to check". The parametrized control over [0, 1, -11, -6] is the right test, and status 0 is the important case: a clean early exit was the one most likely to slip past a returncode check.

Fixed: ask 2, terminality before excludes

jsonrpc/config_reload_helpers.py:81 pins it in the helper ("in_progress" not in output), so every caller gets it rather than the ones that remembered. That is a better fix than the per-call-site pin I asked for. Verified against the product rather than assumed: aggregate_status reports IN_PROGRESS when any subtask is in progress and in the mixed case, so a subtask that has not started cannot make the aggregate look settled.

Fixed: ask 3, and better than asked

I pointed at a YAML file. The fix landed in the framework instead: replay.py:766-770 adds the missing excludes branch, and config.py:121-129 rejects unknown keys at load time. So the next misspelled key fails at collection rather than degenerating into "the file exists". The test_process.py control feeds a real violating file and requires the raise.

Fixed: asks 4, 9, 10, 11, 13, 14

Ask 4: test_abuse_shield.py:116-126 derives the expected rule count from the YAML on disk at start time rather than hardcoding it, covering 11 of 11 instances. The \b after rules is load-bearing, since 1 rules would otherwise prefix-match 10 rules.

Ask 9: the time.sleep(3) is gone, replaced by a bounded poll on config status -c all requiring the token present and in_progress absent, raising with the output on timeout.

Ask 10: reload() now has if token == "rpc-greet": assert not errors ahead of the 6010/6011 check, so the strict rule is restored for the scenario that needs it and the relaxed one stays where it belongs.

Ask 11: test_abuse_shield.py:506 restores the pinned literal with the dots escaped, and the absence of .* between address and actions is what makes an inserted field fail.

Ask 13: the recursive chmod is gone, replaced with verify --with-user <me>, which is the right call rather than a weakening: init creates owner-owned files, so verifying as another user would fail for reasons unrelated to any regression. The deliberately unwritable 0400 file expecting exit 70 is the part I would single out, because without it the tightening would have been self-defeating.

Ask 14: all three sub-complaints closed, and the control parametrizes EADDRINUSE against EADDRNOTAVAIL and ENOBUFS to prove only the first retries.

Partly fixed: ask 5, and the gap is mutation-proven

The destructive half is fixed: replay.py:75-76 and :107 put an embedded replay's sandbox under its owner and pass parent=, so a procedural test using uranium_replay no longer erases its own live tree.

Two things remain. runtime.py:177 still has item_sandbox and procedural_sandbox as byte-identical bodies, and item_sandbox's replay_path parameter is documented in its own docstring and never read, which is why a test helper has to fake it as def item_sandbox(self, *_args). A dead parameter threaded through two call sites is how the next person reintroduces the original bug.

More importantly, test_embedded_replay_preserves_services_and_variants monkeypatches ReplayTest.run away and reimplements the prepare_sandbox(..., parent=...) call in its own fake. Reverting replay.py:107 to prepare_sandbox(self.sandbox) leaves the suite at 100 passed. So the test covers the fixture's wiring, not the line that ships. Mitigating: the real path then raises RuntimeConfigError from runtime.py:206, so it is loud rather than silent data loss.

Partly fixed: ask 6, the guard still dies under -n

plugin.py:126 prints to stderr and that does reach the terminal, which is the half that works. Measured on pytest 9.1.1 with xdist 3.8.0: serially, exit 4 and a clean message. Under -n 2 the message appears, and the run still ends in exit 3 with a 48-line INTERNALERROR traceback on stdout terminating in an unrelated assert not crashitem. Because the message goes to stderr and the dump to stdout, the visible tail is the bogus crashitem, and -n is what tests/README.md tells people to use.

test_collision_diagnostic_reaches_terminal asserts only that the substring appears in stdout + stderr, so it passes while the run still crashes, which locks the half-fix in.

The controller sees every worker's collected ids via pytest_xdist_node_collection_finished(node, ids). Doing the duplicate check there, or gating the raise on not hasattr(config, "workerinput"), gives a clean UsageError in both modes.

Partly fixed: ask 8, resolved by exclusion rather than separation

plugin.py:102-107 takes a non-blocking flock on <sandbox>/.session-lock and a second controller gets a UsageError. That is a defensible resolution and I am not asking you to change the approach. Two holes in it:

The handler catches only BlockingIOError, so on a filesystem where flock is unsupported or unserved (NFS without lockd gives ENOLCK) the OSError escapes pytest_sessionstart as an INTERNALERROR rather than a message. _short_sandbox defaults to /tmp, but --sandbox and ATS_URTEST_SANDBOX are user-supplied and documented.

The collision guard also only sees co-collected items. sandbox_name discards the file path, so a/test_x.py::test_foo and b/test_y.py::test_foo both map to test_foo. A full run catches that; two selective runs each collect one item, never trip the guard, share the deterministic root, and the second rmtrees the first's directory. That matters most under --keep-sandboxes, whose whole purpose is retaining it.

Not addressed: ask 12, the slice alternation

test_slice_stale_generation.py:196-204 is unchanged; the +2 lines are a comment explaining the race. I accept the race is real, and I am not pressing for the single original message. Two things are still worth a line of work.

The alternation is unbracketed, so re.search reads it as (Failed to find a well-formed, completed HTTP response: PARSE_INCOMPLETE) OR (Content-Length body underrun for key mixed), and only the second branch carries key mixed. The first would be satisfied by a PARSE_INCOMPLETE on any key in a multi-key run. Bracketing it costs nothing.

The surrounding assertions also carry more weight than I gave them credit for, so I want to be fair about what is actually exposed: expected_return_code=1 means a complete well-formed response makes the client exit 0 and fail, :203 pins the failure to the mixed key, and assert_mismatch_diagnostics(both_blocks=True) pins two Mismatch/Bad block Content-Range lines with specific blk_range and etag_got. What survives unguarded is narrow: a change in where slice aborts, not whether it aborts.

Non-blocking, from the same delta

process.py:221-231: stop() still calls wait() and discards the reaped status without comparing it to return_codes, and the new gate fires before the SIGTERM. So anything that goes wrong during shutdown is unexamined: a crash in a plugin's shutdown path or a sanitizer report at exit leaves the test green, since close() greps diags_log and not traffic_out. ATS.wait() already validates, which is why the one test that calls it is the one that would catch this. Ask 1 is fixed by wrapping ManagedProcess, not by fixing it, so any future long-running service reintroduces the original gap.

replay.py:801: disable_log_checks is consumed only inside _validate_ats_logs, which only the replay path reaches, so procedural tests have no ERROR: diags check at all, only the FATAL: grep. Five files still pass the dead option. Dropping it from the two ssl reload tests reads as re-enabling log checking, and nothing was re-enabled.

ats.py:380: not self._allow_fatal_diagnostics now gates the unexpected-exit check as well as the fatal-diagnostic one, so a future test that merely tolerates a FATAL line would silently also stop noticing that ATS died. Worth a separate flag.

ats.py:646 uses a bare assert for the missing-log message where the line beside it raises AssertionError explicitly. Under -O the message is lost and the failure becomes a FileNotFoundError traceback.

Method note

Fifteen asks are more than one reader can hold, so this went through the review panel with the asks split by area, and I verified each verdict against the source before repeating it. Two agents disagreed about ask 2 and the disagreement was semantic rather than factual; three asks were not covered by any agent and I checked those myself, which is how 9 and 10 got their verdicts. Not verified by execution on my side: I did not run the suite, so the -n crash and the mutation result are the panel's measurements, reproduced from their commands rather than re-run by me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants